{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# SCI1022 Python workshop 10: A class hierarchy for numerical integration\n",
    "\n",
    "\n",
    "Welcome to the 10th workshop of the Python stream! \n",
    "\n",
    "Some miscellaneous instructions to begin with:\n",
    "\n",
    "* Use your two hours in this workshop to work through this Jupyter notebook. \n",
    "\n",
    "* Read the instructions as you go. Execute the example code cells provided, and look carefully at the output. \n",
    "\n",
    "* Recall that, in order to execute a code cell, then you can left-click on it (either on the interior or the left-hand-side margin of the cell), and then hit `Shift-Enter`, which stands for: \"While holding pressed the `Shift` keyboard key, press the keyboard key labelled as `Enter`\". \n",
    "\n",
    "* If you struggle trying to use the Jupyter notebook, then explore usage instructions and links available at the SCI1022 Python's Moodle page. \n",
    "\n",
    "* <span style=\"color:red\">**Tasks**</span> are marked in <span style=\"color:red\">**red**</span> and displayed in indented blocks. The tasks are solved by writing code on the blank code cells provided.\n",
    "\n",
    "* Ask your instructors for help at any time.\n",
    "\n",
    "---\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "This workshop is the second in a series of two in which we are going to introduce object-oriented programming. We will in particular introduce the programming concept of **class inheritance** and how families of interrelated classes, known as **class hierarchies**, can be built using this idea in order to build programs which are easier to extend and/or modify. \n",
    "\n",
    "We will apply this new feature (class inheritance) to develop a hierarchy of classes to support a variety of numerical integration formulas. These formulas are required in order to numerically compute definite integrals of the form $\\int_{a}^{b} f(x) dx$. Numerical integration is a basic tool in the numerical simulation of application problems with scientific and engineering interest. For example, numerical integration is used in the [Finite Element Method](https://en.wikipedia.org/wiki/Finite_element_method) for approximating the solution of [Partial Differential Equations](https://en.wikipedia.org/wiki/Partial_differential_equation). Besides, numerical integration is absolutely a must in those scenarios in which one does not know the analytical expression of $f(x)$, but $f(x)$ is only known at a set of finite sampling points, which is the usually the case in science.\n",
    "\n",
    "\n",
    "# Object-based versus object-oriented programming\n",
    "\n",
    "\n",
    "Recall that in the previous workshop we introduced the  programming concept of a **class**, i.e., a programmer-defined template, a blue print, a category, that defines the behaviour of the instances (objects) in this category by means of the data (class attributes) and operations (class methods) on such data. In the object-oriented paradigm, programs include class and method definitions, and most of the computation is expressed in terms of operations and interaction among objects.\n",
    "\n",
    "However, **classes and objects alone are not actually the defining property of object-oriented programming.** Indeed, for most practitioners, programming with classes and objects **alone** is actually referred to as **object-based** (versus object-oriented) programming. From this point of view, we have been doing object-based programming since the beginning of the unit, as Python built-in types are indeed classes (e.g. we have instantiated lists and dictonaries, and written computations in terms of their methods). However, the term **object-based** programming is more restricted to the case in which one declares and uses its own classes and objects.  Thus, from this perspective, the previous workshop was the first one in which we wrote object-based programs.\n",
    "\n",
    "If classes and objects alone do not make a programming language **object-oriented**, what feature does? The answer is **class inheritance**. In a nutshell, inheritance is the ability to define a new class that is a modified version of an existing class. Using inheritance, we can create families of interrelated classes, named **class hierarchies**, *that can be viewed as one unit from the outside*. This idea helps to hide implementation details, and makes it easier to modify or extend a program. More to come in the next section. "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Inheritance and class hierarchies\n",
    "\n",
    "**Inheritance** is the ability of a programming language to define a new class that is a modified version of an existing class. As in a biological family, the latter class (i.e., the existing one) is referred to as the **parent class**, while the former class (i.e., the new class defined as the modified version of the parent), to as the **child class**. The parent class is also referred to as **superclass** or **base class**, and the child class, as the **subclass** or **derived class**.  A child class inherits everything from its parent class, both attributes and methods. Besides, the child class can add new attributes and methods, but also modify the implementation of the methods of the parent. This is known as **method overriding**. Thus, a child class can enrich and/or restrict functionality of the parent class.\n",
    "\n",
    "Two possible example scenarios where inheritance comes in handy are:\n",
    "\n",
    "* There is an existing class with some functionality (attributes and methods) that we would like to re-use to some extent in our own class. Instead of copying & pasting the code of that class into our own class, we can instead extend this class by creating a child class and simply add the functionality we need. The original class is still available and the separate child class will likely be much smaller, since it does not need to repeat the code in the parent class. \n",
    "\n",
    "* After developing a set of classes that are conceptually related, we realize that we have to some extent replicated functionality (methods and attributes).  Using inheritance, we can factor out this common functionality  in a parent class, and let all classes to extend this parent class. Depending on the scenario, the parent class might be a class in its full right, i.e., a class that corresponds to an actual concept present in the application at hand, or just a programming artifact to facilitate code re-use. As child classes inherit the common functionality from the parent class, we avoid code replication to a large extent. \n",
    "\n",
    "From these two examples, it comes aparent that inheritance promotes code reusability. However, apart from this, what is so special about inheritance and class hierarchies? **The actual magic of object-oriented programming is its ability to write code that can be re-used with classes that even do not exist when the code is written.** In other words, one piece of code can work with all members in a class family or hierarchy. We can write code that does not need to distinguish whether an object belongs to the parent or the child class.  If we need later on to add new classes to the hierarchy, the code that has been written in terms of the methods common to all classes of the hierarchy does not have to be modified. \n",
    "\n",
    "This salient ability of object-oriented programming, if used judiciously, makes it suitable to develop large, modular software projects that can accommodate growth and changing requirements in a natural way, while making the code more elegant, easier to mantain, and extend.\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Syntax and semantics of class inheritance in Python"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "A compact code pattern that illustrates most of the features of class inheritance is as follows:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class SuperClass:\n",
    "   def __init__(self, p, q):\n",
    "     self.p, self.q = p, q\n",
    "   \n",
    "   def where(self):\n",
    "     print (\"In superclass\", self.__class__.__name__)\n",
    "   \n",
    "   def compute(self, x):\n",
    "     self.where()\n",
    "     return self.p*x + self.q\n",
    "    \n",
    "   def print_p(self):\n",
    "      print(\"Value of attribute p:\", self.p)\n",
    "\n",
    "class SubClass(SuperClass):          # SubClass extends SuperClass\n",
    "   def __init__(self, p, q, a):      # Overrides the __init__ method of SuperClass\n",
    "     SuperClass.__init__(self, p, q) # Explicitly Invoke the constructor of SuperClass\n",
    "     self.a = a\n",
    "   \n",
    "   def where(self):                  # Overrides the where method of SuperClass\n",
    "      print(\"In subclass\", self.__class__.__name__)\n",
    "    \n",
    "   def compute(self, x):             # Overrides the compute method of SuperClass\n",
    "      self.where()\n",
    "      return SuperClass.compute(self, x) + self.a*x**2\n",
    "\n",
    "   def print_a(self):                # Adds a new method to SuperClass \n",
    "      print(\"Value of attribute a:\", self.a)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In the example, we can observe the following:\n",
    "\n",
    "* `SubClass` extends `SuperClass`. This is denoted by `class SubClass(SuperClass):` in the header of the class definition statement of `SubClass`.\n",
    "* The constructor of `SubClass` invokes the constructor of `SuperClass`. As a consequence, `SubClass` inherits the attributes of `SuperClass` (i.e, `p` and `q`). It is generally a good programming practice to always call the superclass constructor in a subclass constructor, although in some cases in might not be required.\n",
    "* `SubClass` adds one attribute (`a`) to `SuperClass`. \n",
    "* `SubClass` adds one method (`print_a`) to `SuperClass`.\n",
    "* `SubClass` inherits one method (`print_b`) from `SuperClass`.\n",
    "* `SubClass` **overrides** the `__init__`, `where` and `compute` methods of `SuperClass`. \n",
    "\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The expected behaviour of such two-level class hierarchy is better illustrated with an example. In the following code, we create an instance of `SuperClass`, called `super`, and an instance of `Subclass`, named `sub`. Then we invoke the `compute` method on `super` and `sub`, respectively."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "super = SuperClass(1, 2)\n",
    "sub = SubClass(1, 2, 3)\n",
    "v1 = super.compute(0)\n",
    "print(\"------------------------\")\n",
    "v2 = sub.compute(0)\n",
    "sub.print_a()\n",
    "sub.print_p()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The magic happens in the `compute` method of `Subclass`.  This method calls `self.where()`, which translates, as regular, to the `where` method in `SubClass`. Then the `compute` method in `SuperClass` is invoked (i.e., `SuperClass.compute(self, x)`). This method, as implemented in `SuperClass`, also makes a `self.where()` call. However, this call triggers the `where` implementation of the `SubClass`, and not the one of `SuperClass`, as it happened when we invoked the `compute` method on `super`. \n",
    "\n",
    "The same effect is shown in the following code cells, by defining a stand-alone function that is supposed to perform some work with the objects in our class hierarchy:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def some_work_to_be_performed_using_class_hierarchy(super):\n",
    "  super.where()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "some_work_to_be_performed_using_class_hierarchy(super)\n",
    "some_work_to_be_performed_using_class_hierarchy(sub)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The function `work_to_be_performed_with_class_hierarchy` works and will work with any class of the hierarchy rooted at `SuperClass`, **even with classes that have not been written yet!**"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# A class hierarchy for numerical integration"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "There are many different numerical methods for computing $\\int_{a}^{b} f(x) dx$. Most of these methods share a common general pattern, but differ in the details regarding how this pattern is specialized for each method at hand. \n",
    "\n",
    "It thus makes sense to apply the idea of object-oriented programming to build a hierarchy of classes for numerical integration. Each class in the hierarchy will represent a different numerical method for computing $\\int_{a}^{b} f(x) dx$. However, the whole class hierarchy will be seen as one unit from the perspective of other parts of the program that have to use it. In this workshop we are going to write such a hierarchy, while trying to grasp which are the benefits underlying object-oriented programming in this particular scenario.\n",
    "\n",
    "# Numerical integration methods\n",
    "\n",
    "In this section, we overview the family of methods subject to implementation. These methods **approximate** $\\int_{a}^{b} f(x) dx$ by evaluating the integrand $f(x)$ at $n$ evaluation points within the interval $[a,b]$. The general pattern shared by all these methods corresponds to the following mathematical formula:\n",
    "\n",
    "$$\\int_{a}^{b} f(x) dx \\approx \\sum_{i=0}^{n-1}w_i f(x_i),$$\n",
    "\n",
    "where $w_i$ are the so-called **integration weights**, and $x_i$ are the **evaluation points** in the interval $[a,b]$, with $i=0,\\ldots,n-1$. The methods differ in the way in which $w_i$ and $x_i$ are defined, as covered in the sequel. The derivation and mathematical analysis of these methods is out of the scope of this workshop. \n",
    "\n",
    "## The Midpoint method\n",
    "\n",
    "Let us define $h=\\frac{b-a}{n}$. The Midpoint method has the evaluation points\n",
    "\n",
    "$$x_i = a + \\frac{h}{2} + ih, \\quad \\quad i=0, \\ldots, n-1,$$\n",
    "\n",
    "and all the weights have the same value given by\n",
    "\n",
    "$$w_i = h, \\quad \\quad i=0, \\ldots, n-1.$$\n",
    "\n",
    "\n",
    "## The Trapezoidal method\n",
    "\n",
    "Let us define $h=\\frac{b-a}{n-1}$. (Note that $h$ is defined differently here compared to the definition of the Midpoint method.) The Trapezoidal method  has the points\n",
    "\n",
    "$$x_i = a + ih, \\quad \\quad i=0, \\ldots, n-1,$$\n",
    "\n",
    "and the weights\n",
    "\n",
    "$$w_0=w_{n-1}=\\frac{h}{2},$$ \n",
    "\n",
    "$$w_i=h, \\quad i=1, \\ldots, n-2.$$\n",
    "\n",
    "\n",
    "## Simpson's method\n",
    "\n",
    "In Simpson's method, **$n$ must be an odd number**. Its evaluation points are defined as in the Trapezoidal method.\n",
    "\n",
    "Let us define $h=2\\frac{b-a}{n-1}$. Simpson's method has the weights:\n",
    "\n",
    "$$w_0=w_{n-1}=\\frac{h}{6},$$\n",
    "\n",
    "$$w_i=\\frac{h}{3}, \\quad i=2,4, \\ldots, n-3,$$\n",
    "\n",
    "$$w_i=\\frac{2h}{3}, \\quad i=1,3,5, \\ldots, n-2.$$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "\n",
    "# Defining classes for numerical integration"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "slideshow": {
     "slide_type": "slide"
    }
   },
   "source": [
    "As mentioned earlier, **we want to implement each of the methods in the previous section on a separate class**. A question that may come into your mind is: *why do we use a class and not a Python function to represent each numerical integration method in the computer?* The answer is that, while one can certainly implement numerical methods for integration as functions, we would be loosing most of the benefits associated to object-oriented programming, as discussed along the present section. While reading the section, it would be a nice exercise to ask yourself and reflect about the following question: *if I used functions, would I retain the benefits of using a class hierarchy, or would I loose something instead?*\n",
    "\n",
    "Let us forget about class inheritance for a moment. If we implement each numerical method in a separate class, chances are high that we end with 4 classes (note that we have 4 methods), with the following general pattern for each class:\n",
    "\n",
    "```Python\n",
    "class SomeIntegrationMethod:\n",
    "   def __init__(self, a, b, n):\n",
    "     # Compute self.points and self.weights for the particular method \n",
    "     # at hand accordingly to the formulas in the previous section   \n",
    "   def integrate(self, f):\n",
    "      s = 0\n",
    "      for i in range(len(self.weights)):\n",
    "        s += self.weights[i]*f(self.points[i])\n",
    "      return s\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Do you foresee any issue with this approach? What happens, e.g., if after writing the `integrate` method for all 4 classes, we decide that it is better to vectorize the `for` loop using `numpy.dot`? What if we want to extend the program with  additional integration methods? Think for a second before continue reading.\n",
    "\n",
    "**Making such classes for many different integration methods soon reveals that all the classes contain common code, namely the `integrate` method.** Therefore, to place this common code in a superclass is a wise thing to do in order to reduce code replication. Subclasses can extend the superclass and just add the code that is particular to the numerical method at hand, namely the weights $w_i$ and evaluation points $x_i$. \n",
    "\n",
    "A possible implementation of this superclass is as follows:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Integrator:\n",
    "  def __init__(self, a, b, n):\n",
    "    self.a, self.b, self.n = a, b, n\n",
    "    self.points, self.weights = self.compute_points_and_weights()\n",
    "    \n",
    "  def compute_points_and_weights(self):\n",
    "    raise NotImplementedError(f\"Weights/Points not defined in class {self.__class__.__name__}\")\n",
    "    \n",
    "  def integrate(self, f):\n",
    "    s = 0\n",
    "    for i in range(len(self.weights)):\n",
    "       s += self.weights[i]*f(self.points[i])\n",
    "    return s"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The constructor of the class (i.e., the `__init__` special method) builds a new instance of the class with five attributes, namely the lower limit of the definite integral (`self.a`), the upper limit (`self.b`), the number of evaluation points (`self.n`), and the evaluation points (`self.points`) and weights (`self.weights`) of the integration method. While the values of the former three attributes are just taken from the corresponding `__init__` method parameters, i.e., `a`, `b`, `n`, the ones of the latter two attributes are initialized to the returned values of a separate method of the class, `compute_points_and_weights`. \n",
    "\n",
    "**The `compute_points_and_weights` is thought to be implemented in the subclasses (i.e., child classes) of `Integrator`.** However, the `Integrator` class defines a default implementation of this method. This method raises an exception (i.e., a run-time error) telling the user that the method is not implemented. If we forget to **override** the method when writing the code of a subclass, the method of the superclass will be inherited, and it  will issue an error message if we call it with an instance of the subclass. This is illustrated in the following code cells:\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "class Midpoint(Integrator): # Define a new child class (subclass) of Integrator\n",
    "                            # The class inherits compute_points_and_weights as \n",
    "                            # implemented in Integrator\n",
    "    pass"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "mid = Midpoint(a=0,b=10,n=100)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can see from the execution of the code cell above that the construction of this error message is quite clever in the sense that it tells in which class the method is missing, `Midpoint` in this case (`self` will be the subclass instance and `self.__class__.__name__` is a string with the corresponding subclass name).\n",
    "\n",
    "On the other hand, `__init__` and `integrate` are thought to be inherited as-is by the subclasses of `Integrator`. It is possible to override them, but there is certainly no benefit from this in our particular scenario.\n",
    "\n",
    "\n",
    "><span style=\"color:red\">**Task 1.**</span>  We would like to provide the users of our class hierarchy a faster alternative to the `integrate` method. In particular, we would like all classes in our hierarchy to also have a `vectorized_integrate(self,f)` method that uses NumPy vectorization to compute the integral numerically. To which class do we have to add this method to achieve our goal? Write the class definition statement with this new method added in the code cell below. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 1 goes here (class definition statement)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    " Let us now implement the first subclass of `Integrator`, the one that implements the Midpoint method. \n",
    " \n",
    " ><span style=\"color:red\">**Task 2.**</span>  Write a class `MidpointIntegrator` that implements the Midpoint method. Some considerations and hints:\n",
    " > * This class must be a subclass of `Integrator` (think why!) \n",
    " > * This class must define a single method that **overrides** the `compute_points_and_weights` of the parent class (think why!). \n",
    " > * The method must return two NumPy 1D arrays (think why!).\n",
    " > * The generation of evaluation points and weights can be vectorized using `numpy.linspace` and `numpy.zeros`, respectively. However, a scalar version of the method is also accepted for this task."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 2 goes here (class definition statement)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let us know use `MidpointIntegrator`, e.g., for approximating $\\int_{0}^{2} x^2 dx$ using $n=100$ evaluation points. This is achieved using the following code:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def f(x):         # Function to integrate, i.e., integrand\n",
    "    return x*x\n",
    "\n",
    "mid = MidpointIntegrator(a=0,b=2,n=100)\n",
    "print(mid.integrate(f))\n",
    "print(mid.vectorized_integrate(f))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Note that the exact integral is equal to $\\frac{8}{3}$."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    " ><span style=\"color:red\">**Task 3.**</span> Describe with your own words the **program flow** triggered by the instruction `mid = MidpointIntegrator(a=0,b=2,n=100)` in the code cell above. At the very minimum, you should include in your answer an ordered list of the methods which are invoked and to which class these methods belong. **Hint:** If you struggle finding an answer to this question, [Online Python Tutor](http://www.pythontutor.com/visualize.html) may come in handy. "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    " <span style=\"color:red\">**Write your answer to Task 3 here.**</span>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In the rest of this section, the goal is to implement the remaining two methods.\n",
    "\n",
    "><span style=\"color:red\">**Task 4.**</span>  Write a class `TrapezoidalIntegrator` that implements the Trapezoidal method. The generation of evaluation points and weights can be vectorized. However, a scalar version of the method is also accepted for this task. We will verify (test) the implementation in a later section."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 4 goes here (class definition statement)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "><span style=\"color:red\">**Task 5.**</span>  Write a class `SimpsonIntegrator` that implements Simpson's method. \n",
    "The code cell below provides a starting skeleton that you have to complete. Note that the number of evaluation points in Simpson's method must be odd. Thus, if the number of evaluation points is even, the user is notified, and an exception that stops the execution of the program is raised. Some hints and considerations. \n",
    ">* The generation of evaluation points and weights can be vectorized. However, a scalar version of the method is also accepted for this task. \n",
    ">* The `range` function accepts a third argument that controls the stride among two consecutive numbers in the range. Thus, e.g., `range(1,10,2)`, corresponds to the sequence `1,3,5,7,9`, and `range(2,10,3)`, to the sequence \n",
    "`2,5,8`.\n",
    ">\n",
    "> We will verify (test) the implementation in a later section."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 5 goes here (class definition statement)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Verification\n",
    "\n",
    "To verify the software developed so far we will use the fact that **all methods implemented are able to integrate linear functions exactly.** Recall from the previous workshop that the convention to verify classes is to develop a stand-alone function, with name `test_Integrator` (in our particular scenario), and no arguments, that performs the necessary verification tests.\n",
    "\n",
    "><span style=\"color:red\">**Task 6.**</span>  Complete the body of the `test_Integrator` function provided in the cell below. You must use the `assert` statements. Check that not `AssertionError` is raised, i.e., that all tests pass."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def test_Integrator():\n",
    "  def f(x):\n",
    "    \"Function to integrate, i.e., integrand\"     \n",
    "    return x + 2\n",
    "    \n",
    "  def F(x):\n",
    "    \"Primitive function, i.e., exact integral of f(x)\"     \n",
    "    return 0.5*x*x\n",
    "    \n",
    "  def rel_error(x,y):\n",
    "    return abs(x-y)/abs(y)\n",
    "    \n",
    "  a=2\n",
    "  b=3\n",
    "  n=5\n",
    "  rtol=1e-14\n",
    "  exact_integral=F(b)-F(a)\n",
    "\n",
    "  for Integrator in [MidpointIntegrator,___]: # Complete list of class objects\n",
    "        integrator      = ___ # build an instance of Integrator\n",
    "        approx_integral = ___ # compute integral using integrate method\n",
    "        ___                   # check that the relative error is lower than rtol (with assert)\n",
    "        \n",
    "        approx_integral = ___ # compute integral using vectorized_integrate method\n",
    "        ___                   # check that the relative error is lower than rtol (with assert)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "test_Integrator()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Using the class hierarchy\n",
    "\n",
    "Let us now stress our class hierarchy to compute a more challenging integral from a numerical point of view. Namely, we want to compute the value:\n",
    "\n",
    "$$\\int_0^1 (1+\\frac{1}{m})t^{\\frac{1}{m}} dt = 1.$$\n",
    "\n",
    "The integrand of this integral has one independent variable, $t$, with respect to which we want to integrate, but it has also a parameter, $m$. **The classes in our hierarchy assume that the functions to be integrated are functions of a single variable.** (Look at the implementation of the `integrate` method to understand at which particular point we are enforcing such a constraint.) In the previous workshop we already discussed about the convenience of representing functions with parameters using classes.\n",
    "\n",
    "><span style=\"color:red\">**Task 7.**</span>  Write a class definition statement for a class named `F` which represents functions of the form $F(t;m)=(1+\\frac{1}{m})t^{\\frac{1}{m}}$. How many and which attributes should this class have? And methods? Recall that we have to implement the `__call__` special method if we want to call instances of class `F` as if they were functions. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 7 goes here (class definition statement)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We now want to find an answer to the following relevant question: *at which rate the relative error in the integral drops down as we increase $n$?* The mathematical analysis of these methods advances that the relative error should decay exponentially with $n$. Thus, in order to evaluate in a plot relative error versus $n$, it helps to represent the logarithm of the error versus the logarithm of $n$. If we do that, we turn an exponential relation into a straight line (assuming that we use a linear scale for both the $x$ and $y$ axis), and the steeper the line is (i.e., the larger the magnitude of the slope), the faster the error goes to zero as $n$ increases.\n",
    "\n",
    "><span style=\"color:red\">**Task 8.**</span>  Write a function `rel_error_versus_n` that given: (1) an integrand (i..e, a callable object); (2) the exact integral value (i.e., a floating point number); (3) a list of values of $n$; (4) an integration method (i.e., a class object in the `Integrator` hierarchy); (5) the value of $a$ and (6) the value of $b$, returns a pair of lists. The first list must contain the logarithm of the values of $n$, and the second, the logarithm of the relative error corresponding to each value of $n$. \n",
    "\n",
    " "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 8 goes here (function definition statement)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now that we have the data, we are ready to plot it. In particular, we want to generate two plots. Each plot will show the relation among (log) relative error versus (log) $n$ for the three methods implemented. However, the first plot will do it for the integrand $F(m=\\frac{1}{4};t)=5t^4$, while the second, for the integrand $F(m=2;t)=\\frac{3}{2}\\sqrt{t}$.\n",
    "In both cases, we will evaluate the integral in the interval $[a,b]=[0,1]$.  The exact value of both integrals is equal to 1. \n",
    "\n",
    "><span style=\"color:red\">**Task 9.**</span>  Write code to generate the two plots mentioned in the paragraph above. Which method converges faster? Which converges slower? Recall that the larger the slope of the straight line in magnitude, the faster the convergence. Equal slope means equal convergence speed. Do you observe the same behaviour for both integrands?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 9 goes here (code to generate first plot)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Solution to Task 9 goes here (code to generate second plot)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<span style=\"color:red\">**Write your answer to the questions in Task 9**</span>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "To end the section, can you think for a second how much effort would it take to adapt the code written in Task 8 and 9 if we wanted to add a new numerical integration algorithm, such as, e.g., [the two-point Gauss-Legendre method](https://en.wikipedia.org/wiki/Gauss%E2%80%93Legendre_quadrature)? **Ladies and gentlemen, with you the magic of object oriented programming!**\n",
    "\n",
    "\n",
    "\n",
    "# Learning outcomes"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In this workshop we finished our (unfortunately brief) two-workshop coverage of object-oriented programming. In particular, we presented a salient feature that actually makes an object-based programming language, i.e., one which is based on classes and objects, object-oriented, namely **class inheritance**. Using inheritance we can build class hierarchies, i.e., families of different realiztions of the same abstraction, and expose such families as a single unit to other parts of the program. \n",
    "\n",
    "This has several benefits:\n",
    "* We can hide implementation details, and focus on abstractions instead.\n",
    "* We are able to extend the software with new features (e.g., a new integration method), by adding additional classes, without having to modify code that depends on the class hierarchies. \n",
    "* We achieve higher modularity, by minimizing dependencies among different parts of the code. \n",
    "\n",
    "Such kind of benefits are essential to have code that better reflects the application problem at hand, i.e.., more elegant code, and more importantly, code that can growth large naturally over time, and meet evolving requirements."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.7.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
